Skip to content

fix(compiler): an unresolved alpha corrupts the colour beside it - #421

Open
YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/unresolved-color-channel-scale
Open

fix(compiler): an unresolved alpha corrupts the colour beside it#421
YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/unresolved-color-channel-scale

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Problem

lightningcss hands the compiler an UnresolvedColor when every channel of a colour is known but its alpha is a var() — the shape rgb(239 68 68 / var(--tw-bg-opacity)) takes. Four defects live in the one function that consumes it, parseUnresolvedColor, and each produces a different wrong outcome.

Measured by rendering each declaration through @testing-library/react-native on the base commit (f70c402):

declaration reaches React Native as processColor paints
rgb(255 0 0 / var(--a, 0.5)) rgba(65025, 0, 0, 0.5) 0x80ff0000 correct red, by accident
rgb(50% 25% 10% / var(--a, 0.5)) rgba(32640, 16320, 6630, 0.5) 0x80ffffff white — the intended brown is 0x8080401a
hsl(0 84.2% 60.2% / var(--a, 0.5)) hsl(0, 84.19999694824219, 60.20000076293945, 0.5) undefined nothing — the property is unset
hsl(calc(NaN) 100% 50% / var(--a, 0.5)) hsl(Infinity, 100, 50, 0.5) undefined nothing — the property is unset
hsl(1e20 100% 50% / var(--a, 0.5)) hsl(9223369837831520000, 100, 50, 0.5) undefined nothing — the property is unset

(processColor measured against the real @react-native/normalize-colors at react-native 0.81.4, the version this repo installs.)

Row one is why row two survived: a saturated channel is indistinguishable from a channel that overflowed and clamped, so the defect is invisible on exactly the colours a probe reaches for first.

Root cause

1 — the rgb channels are scaled twice, and only one multiplication is in this repository

lightningcss converts to 0-255 before any JavaScript runs. rgb(50% 25% 10% / var(--a)) arrives as { r: 128, g: 64, b: 26 } — fifty percent of 255 is 127.5, which rounds to 128. parseUnresolvedColor then multiplied again (round(color.r * 255) and its two siblings).

The proof that 0-255 is the field's range is in the same file and unchanged by this branch: parseColor reads the same shaped channel and divides by 255, because colorjs.io wants 0-1. The unresolved path emits a CSS rgba() string, which takes 0-255 — so it needed no conversion in either direction.

React Native then hides the arithmetic: parse255 clamps anything above 255, so the string is accepted and the colour is wrong rather than absent.

2 — an unresolved hsl() is emitted in a spelling React Native's parser rejects

lightningcss resolves the hue, saturation and lightness to plain numbers with no unit attached, and the base code passed those through under color.type — producing a four-argument hsl(…) with no percent signs. @react-native/normalize-colors builds hsl with three capture groups and hsla with four; neither matches, control reaches return null, and processColor turns that into undefined.

The arity requirements of hsl and hsla are inverted, and that is what decides the fix. Measured:

string processColor
hsl(0, 84.19999694824219, 60.20000076293945, 0.5) — what the base emits undefined
hsla(0, 84.2%, 60.2%, 0.5) — the working hsl-family spelling 0x80ef4444
hsla(0, 84.2%, 60.2%) — the same, alpha var() resolved to nothing undefinedhsla requires four
rgba(239, 68, 68, 0.5) 0x80ef4444
rgba(239, 68, 68) — alpha var() resolved to nothing 0xffef4444 — opaque, and still renders

An UnresolvedColor guarantees the alpha is a var(), and a var() that resolves to nothing is dropped from the argument list at runtime (src/native/styles/resolve.ts filters undefined out of the array before joining). So no hsl-family spelling survives both states: hsla is rejected three-argument, hsl is rejected four-argument. rgba survives both, which is why the fix converts the spelling rather than correcting it.

3 — a non-finite hue arrives as Infinity

The compiler runs lightningcss twice, the second pass over the first's serialized output, and that round trip is the mechanism: a single pass over hsl(calc(NaN) …) gives h: NaN, pass one serializes it past the float range, and pass two reparses it as Infinity. So Number.isNaN is the wrong test and Number.isFinite is the right one — for this input.

That the hue is the only channel needing a guard is measured rather than assumed. lightningcss clamps every other channel before it arrives, so the same calc(NaN) in any other position is harmless — rendered on the base commit:

declaration reaches React Native as
hsl(calc(NaN) 100% 50% / var(--a, 0.5)) hsl(Infinity, 100, 50, 0.5) — the hue survives
hsl(0 calc(NaN * 1%) 50% / var(--a, 0.5)) hsl(0, 100, 50, 0.5) — saturation clamped
hsl(0 100% calc(NaN * 1%) / var(--a, 0.5)) hsl(0, 100, 100, 0.5) — lightness clamped
rgb(calc(NaN) 0 0 / var(--a, 0.5)) rgba(65025, 0, 0, 0.5) — channel clamped to 255, then scaled by defect 1

4 — Infinity is not the only way to arrive without a hue

A finiteness test catches only the input that overflows. The same round trip saturates other inputs, and those arrive finite. Measured at the point parseUnresolvedColor reads them:

declaration color.h
hsl(calc(NaN) …) Infinity
hsl(calc(infinity) …) 9223372036854776000
hsl(1e20 …) 9223369837831520000
hsl(1e38 …) 9223369837831520000 — the same value
hsl(4294967296 …) 4294969856
hsl(1e7 …) 10000000 — carried exactly

1e20 and 1e38 landing on one number is the whole finding: past the ceiling the declaration's hue is not carried at all, so reducing what arrives modulo 360 reports the serializer rather than the stylesheet.

Both passes saturate, and they saturate different inputs. A visitor is what materialises the AST into JavaScript and back, and the hue saturates to i64 on that round trip. Measured by toggling each visitor independently:

hue serialized after pass 1 pass 1 visitor pass 2 Rule visitor arrives as
1e20 9223370000000000000 saturates it no effect 9223369837831520000
1e38 9223370000000000000 saturates it no effect 9223369837831520000
calc(infinity) 3.40282e38 no effect saturates it 9223372036854776000

Removing pass one's declaration visitor leaves 1e20 serialized verbatim as 100000000000000000000; removing pass two's rule visitor leaves calc(infinity) arriving unsaturated at 3.402820018375656e+38. So the headline calc(infinity) case is pass two's, and the 1e20 / 1e38 collision is pass one's.

That six-significant-digit serialization of the saturated value is also the collision mechanism: pass one saturates to i64 max, writes it back as 9223370000000000000, and pass two reparses that single string as an f32. colorjs.io does exactly that and hands back a plausible colour — rgba(255, 34, 0, …) for calc(infinity), rgba(255, 0, 102, …) for the other two.

The guard's subject was wrong, not its threshold. A hue reaches this function as a 32-bit float, and past a point that float stops naming an angle at all — not merely naming one imprecisely. A float32 holds a 24-bit significand, so its ULP at 2 ** exponent is 2 ** (exponent - 23) and first covers a whole turn at 2 ** 32; past there every representable neighbour lands on a different angle. Infinity is the same condition at the top of the range.

The distinction matters because imprecision starts much earlier and is a different defect, with a different cause and no fix in this function — see KNOWN LIMITS.

Fix

Five commits, one source file, in dependency order.

  1. 7d69ff1 — stop scaling. The three round(color.* * 255) expressions become the channels themselves, with a comment naming lightningcss's own conversion.
  2. e160a8c — emit rgba for hsl. The hsl arm routes through colorjs.io and a toRgbChannel helper converts 0-1 sRGB back to 0-255, so both spellings of one colour compile to one descriptor.
  3. 69e1ff9 — guard a non-finite hue. Number.isFinite(color.h) ? color.h : 0.
  4. 287cf8e — guard every hue the float grid cannot name. That expression becomes a named toHueDegrees, testing Number.isFinite(hue) && Math.abs(hue) < 2 ** 32.
  5. 8c5c581 — correct what the comments assert, and close two test gaps. The only runtime change is deleting a dead clause: Number.isFinite(hue) && cannot alter a result, because Math.abs(NaN) < x and Math.abs(±Infinity) < x are both false. Removing it leaves all 55 cases in the two files green. The guard is now Math.abs(hue) < 2 ** 32.

Zero is the value CSS Color 4 gives a missing component. It is also, measured, the hue lightningcss's own resolved path lands on for 2 ** 32, 1e10, 1e15, 1e20, 1e30 and 1e38 — all six resolve to red — so the guard makes the two spellings agree rather than picking a house answer. A hue that is large but still nameable is reduced exactly as before: hsl(-600 …) compiles to 0, 255, 0 and hsl(1e7 …) to 170, 0, 255, both matching their resolved twin, and hsl(3e9 …) to 0, 255, 0.

Of the six hues above, three are rows in the parity list — 2 ** 32, 1e20 and 1e38 — and those three are what the guard flips from disagreeing to agreeing. 1e10, 1e15 and 1e30 resolve red too but are not rows.

The four are independent defects in one function sharing one parity test, so they can be taken separately — though 3 and 4 are meaningless without 2, and 4 subsumes 3.

Which plane

Compiler (src/compiler/declarations.ts, alone), reaching only the native runtime. Web needs no mirror: the CSS is served to the browser, which resolves rgb() / hsl() with a var() alpha itself. parseUnresolvedColor has no counterpart under src/web — there is no hsl, no UnresolvedColor, and no colour parsing in those four files.

Tests

36 new, in two existing files. Running both files against the base commit: 34 fail, 21 pass of 55. The two files held 19 cases before this branch, all 19 still pass on the base, so the 21 is those 19 plus two of the additions — and 34 of the 36 additions are red on the base.

(The 34/21 split coincides exactly with the two files' own totals, colors.test.tsx holding 34 cases and declarations.test.tsx 21. That reads like a pass/fail split transcribed from per-file counts and is not one; both figures were re-measured for this revision.)

  • src/__tests__/compiler/declarations.test.tsx — 11 rows in the existing table, asserting the emitted descriptor.
  • src/__tests__/native/colors.test.tsx, describe("unresolved alpha") — rgb with number channels, rgb with percentage channels, the hsl conversion, a test.each over six hues the float grid cannot name, a test.each over three large hues that must still be reduced rather than clamped, each chosen to land on a colour the clamp does not also produce, and a light-dark() carrying an unresolved alpha into both schemes, which is what proves the recursive call carries both new shapes.
  • src/__tests__/native/colors.test.tsx, describe("unresolved alpha matches the resolved spelling") — a test.each over eleven colours renders each twice, once resolved and once with / var(--a, 1)) spliced in, and asserts processColor returns the same number for both.

The two additions that pass on the base are the finding, not a gap: they are the parity rows rgb(255 0 0) and rgb(100% 0% 0%), the two whose channels were already saturated, so React Native's clamp lands them on the right colour anyway. They are the reason this shipped.

That parity test first asserts typeof expected === "number", so the comparison cannot pass vacuously by both sides being undefined — which is exactly what a rejected colour produces.

The guard is pinned in both directions, and the constant itself is bracketed. Reverting toHueDegrees to the finiteness test alone turns 12 of the 55 cases in these two files red, three of them parity rows (hsl(4294967296 …), hsl(1e20 …), hsl(1e38 …)). Widening it — 2 ** 32 replaced with 360 — turns 6 red instead, all large-nameable-hue rows.

The constant is bracketed by rewriting it and re-running both files. The green window is [3000000001, 4294969856] — a factor of 1.43, with 2 ** 31, 2 ** 33, 1e10 and 10000001 all red. The upper edge is 2 ** 32's own arriving value, 2560 above the constant; the lower edge is the 3e9 row, which sits between 2 ** 31 and 2 ** 32 where the float32 ULP is 256 and so still finer than a turn, and which arrives exactly because one significant digit survives any serializer.

That lower edge is new in 8c5c581. Before it the window was [10000001, 4294969856], a 429-fold interval that 2 ** 31 sat inside, because the widest nameable row was 1e7. The row replaced to close it was hsl(720 …), which could not fail: 720 reduces to 0, the same answer the clamp gives, so it survived a mutation setting the threshold to 1 while both its siblings went red.

Full suite, typecheck and lint measured on the same machine, same worktree layout. Base f70c402 is 1048 passed / 3 failed / 1072 total; this branch is 1084 passed / 3 failed / 1108 total, unchanged by 8c5c581. The 3 failures are the two src/__tests__/babel/* suites, which fail identically at every ref on Windows — the suite line is 2 failed, 4 skipped, 53 passed, 55 of 59 total at both refs. yarn typecheck and yarn lint exit 0 on both.

KNOWN LIMITS

Unnameable hues are now deterministic, and a family of them still disagrees with its resolved twin. They compile to the same colour as every other unnameable hue, rgba(255, 0, 0, …), while lightningcss folds a resolved twin such as hsl(calc(infinity) 100% 50%) to #000.

calc(infinity) is not alone in this and is not a special case. Sampling f32 hues above 2 ** 32, 100 of 672 — about one in seven — resolve to something other than red, and not always black: 6.135668e9 resolves #0f8. Named members include 5e10, 1e11, 1e12, 1e18, 1.41e38, 1.44e38, 3.4e38 and calc(-infinity).

That #000 is not a target worth matching, and the reason is stronger than lightningcss being erratic — it is deterministic, and it is still unmatchable here, because the information that decides it never reaches this compiler.

Seventeen authored hues — 1e19, 2e19, 5e19, 1e20, 1e25, 1e30, 1e35, 1e38, 1.40e38, 1.41e38, 1.42e38, 1.44e38, 1.46e38, 2e38, 3e38, 3.4e38, 9223372036854775807 — all arrive at parseUnresolvedColor as the single value 9223369837831520000. lightningcss's own resolved path splits that one arriving value twelve red to five black. No function of the hue this compiler receives can separate seventeen inputs it receives as one number, so matching the resolved path is not a harder problem here; it is an impossible one, and it stays impossible however lightningcss behaves.

(An earlier revision of this section argued the resolved answer "is not a function of the hue". That is refutable in one command, and it is worth recording the refutation rather than quietly dropping it: h - f32(f32(h / 360) * 360) !== 0 predicts #000, else red, and it fits 576 of 576 sampled f32 hues above 2 ** 32. lightningcss is fully deterministic here. The refutation does not rescue the residual, because that predicate is a function of the authored hue — which is exactly the value the compiler never receives.)

So the branch emits the answer that IS a function of the arriving hue, and src/__tests__/native/colors.test.tsx pins the divergence with a test that goes red if lightningcss ever stabilises — which is when the row belongs in the parity list instead. Compared with the base commit this is a strict improvement: the base paints nothing at all for these hues and disagrees just as much. Measured, the guard turns 12 assertions across the two files from red to green.

A hue below 2 ** 32 can still be carried imprecisely, and this branch does not change that. The threshold is about where a hue stops naming an angle, not about where it stops being exact.

The binding wall below the threshold is lightningcss's six-significant-digit serializer, not the float32 ULP grid, and it bites from about 1e6 — three decades below 2 ** 32. Measured: 1234567 arrives as 1234570, 12345678 as 12345700, 123456789 as 123457000, and 2147483648 serializes to 2147480000 and arrives as 2147480064.

Sampling forty full-precision hues per decade, the arriving hue differs from the authored one in 0/40 below 1e6 (an integer under 1e6 has six digits and survives), then 38/40 in [1e6, 1e7) and 40/40 in every decade above. Parity is already broken across that range on the base commit and after — hsl(12345678 …) gives (0, 85, 255) against the resolved (0, 178, 255), hsl(1e9 …) (170, 0, 255) against (68, 0, 255), hsl(3e9 …) (0, 255, 0) against (255, 0, 0).

The information is gone before this function runs; closing it means a serializer change upstream. A second, smaller divergence lives beside it and is also untouched: lightningcss reduces the hue in f32 while colorjs.io reduces in f64, so even a losslessly carried hue can differ by a unit — hsl(198 …) gives (0, 179, 255) against the resolved (0, 178, 255).

Tailwind v4's opacity modifier never reaches this path. v4 emits color-mix(in oklab, var(--c) 10%, transparent), which routes through parseColorMix — measured, color-mix(in oklab, red 10%, transparent) compiles to #ff00001a on both refs. What this fix does make correct is v3-shaped output (background-color: rgb(239 68 68 / var(--tw-bg-opacity))), which on the base clamps to white.

none channels and every colour space outside rgb/hsl are still broken, and they never reach this function. UnresolvedColor has exactly three members, which is why parseUnresolvedColor closes on color satisfies never. Measured on both refs:

declaration reaches React Native as
oklch(0.7 0.1 30 / var(--a, 0.5)) property dropped entirely
hwb(120 30% 40% / var(--a, 0.5)) property dropped entirely
rgb(none 0 0 / var(--a, 0.5)) rgb(none, 0, 0, 0.5) — rejected, property unset
hsl(none 100% 50% / var(--a, 0.5)) hsl(none, 100%, 50%, 0.5) — rejected, property unset

The last two are a live sibling of defect 2: they route through unparsedFunction, which still emits the four-argument hsl(...) spelling with the same rejection shape and none of the fix. It is one function away; I left it out to keep this diff to the one function, and would rather do it as its own change than widen this one.


Related. #351 — which is this branch's base commit — fixed the same class of defect in the same file: an invalid colour string React Native discards to null, leaving the property unset. #317 is the report behind it.

Overlaps with open PRs. Measured with git merge-tree against every open PR head:

Whichever of #391 / #346 lands second needs a rebase, but only in the test files.

lightningcss resolves the rgb channels of an `UnresolvedColor` to integers in the
0-255 range before handing them over, the percentage syntax included, so
multiplying by 255 again produces impossible channels: `rgb(255 0 0 / var(--a))`
compiled to `rgba(65025, 0, 0)`.

React Native clamps to 255, which hides the defect while every channel is already
saturated, but destroys any other colour: `rgb(50% 25% 10% / var(--a))` reached the
view as `rgba(32640, 16320, 6630)` and painted white instead of brown.

`parseColor` reads the same 0-255 channels and divides by 255 for colorjs.io's 0-1
sRGB space. The unresolved path emits a CSS `rgba()` string, so its channels pass
straight through.
`parseUnresolvedColor` emitted the hue, saturation and lightness as bare numbers,
so `hsl(0 84.2% 60.2% / var(--a))` reached the view as
`hsl(0, 84.19999694824219, 60.20000076293945, 0.5)`. React Native reads no
percentage units on the saturation and lightness there, normalizes the whole
declaration to null, and leaves the property unset.

An `hsla()` spelling carrying those units is accepted, so a valid hsl form does
exist, but it degrades badly under the one thing an `UnresolvedColor` guarantees:
the alpha is a `var()`. An unset variable with no fallback drops the argument,
which leaves `hsla()` three-argument and rejected, while `rgba()` stays valid and
renders opaque.

lightningcss resolves every channel of an `UnresolvedColor` and leaves only the
alpha open, so the hue, saturation and lightness convert to the sRGB channels
`parseColor` writes for the resolved spelling. Both spellings then compile to one
colour, and a dropped alpha degrades the same way in each.
lightningcss clamps saturation, lightness and every rgb channel to their range,
which leaves the hue as the one unbounded channel: it serializes a non-finite
`calc()` hue as a float that reparses to `Infinity`. colorjs.io reduces a hue
modulo 360, so such a hue spreads `NaN` across all three sRGB coordinates and
`hsl(calc(NaN) 100% 50% / var(--a, 0.5))` reaches the view as
`rgba(NaN, NaN, NaN, 0.5)`, which normalizes to null and leaves the property
unset.

The resolved spelling of that declaration renders red, because lightningcss folds
it to a colour before `parseColor` ever runs. colorjs.io serializes a non-finite
hue as `#NaNNaNNaN` rather than coercing it, so reading `coords` directly is what
exposes the divergence at exactly this input. A hue that is not a real number now
takes the `0` CSS Color 4 gives a missing component, which is also the hue
lightningcss resolves the same declaration to.

`Number.isNaN` does not cover this, so the guard tests for a finite value: the
hue arrives as `Infinity`, not as `NaN`.

The added parity cases assert that each unresolved spelling and its resolved twin
reach React Native as one colour, which is the property both fixes establish.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft August 15, 2026 14:35
The hue guard tested `Number.isFinite`, which only catches a hue that arrives as
`Infinity`. A `calc(infinity)` hue does not: the compiler runs lightningcss twice
and the second pass reparses the first pass's serialized output, where the hue
saturates rather than overflowing. Measured through the compiler,
`hsl(calc(infinity) 100% 50% / var(--a, 1))` arrives as `9223372036854776000`,
and `hsl(1e20 ...)` and `hsl(1e38 ...)` both arrive as `9223369837831520000` —
the same value, because past the ceiling the declaration's hue is not carried at
all.

colorjs.io reduces those modulo 360 and hands back a colour, so the compiler
emitted `rgba(255, 34, 0, ...)` for the first and `rgba(255, 0, 102, ...)` for the
other two, none of which the declaration asked for and none of which agrees with
the resolved spelling of the same colour.

The guard's subject was wrong rather than its threshold. A hue arrives as a
32-bit float, and reducing one modulo a turn only says something about the
author's angle while that float still resolves finer than the turn. A float32
holds a 24-bit significand, so its ULP at `2 ** exponent` is
`2 ** (exponent - 23)` and first covers a whole turn at `2 ** 32`; past there
every representable neighbour lands on a different angle. `Infinity` is the same
condition at the top of the range, which is why one test covers both.

Such a hue now takes the `0` CSS Color 4 gives a missing component. Measured
against the resolved spelling, that is also what lightningcss itself resolves
`2 ** 32`, `1e10`, `1e15`, `1e20`, `1e30` and `1e38` to, so six more rows join the
parity property this branch establishes, and `hsl(-600 ...)`, `hsl(720 ...)` and
`hsl(1e7 ...)` are reduced as before rather than clamped.

`calc(infinity)` is the one input left out. lightningcss resolves a hue that
large in 32-bit floats and its answer is not a function of the hue — `1.40e38`
resolves red, `1.42e38` black, `1.46e38` red again — so there is nothing to
match. The compiler produces the answer that IS a function of the hue, and a
test pins the divergence so it goes red if lightningcss ever stabilises.
The runtime behaviour is unchanged. Three claims that shipped with the guard
were refutable, and the residual's justification was the weakest of them.

`calc(infinity)` was described as the one input left out. It is a family:
sampling f32 hues above `2 ** 32`, about one in seven resolves to something
other than the red the compiler emits — `5e10`, `1e12`, `1e18`, `1.44e38` and
`calc(-infinity)` among them. The commit that added the guard cited three of
these as evidence without noticing they were the same divergence class.

The reason given for leaving that residual open — that lightningcss's answer is
not a function of the hue — is refutable in one command. It is deterministic.
The real reason is stronger. Seventeen authored hues from `1e19` to
`9223372036854775807` all reach the compiler as the single value
`9223369837831520000`, and lightningcss's resolved path splits that one arriving
value twelve red to five black. The information separating them is destroyed
before the compiler sees it, so no function of the arriving hue can reproduce
the split — not because lightningcss is erratic, but because the compiler is
handed one number for seventeen inputs.

The pass attribution was also wrong. A visitor is what materialises the AST into
JavaScript and back, and the hue saturates to i64 on that round trip. Pass one's
declaration visitor saturates `1e19` through `1e38`, serializing all of them as
`9223370000000000000`; pass two's rule visitor saturates `calc(infinity)`, which
pass one leaves at the float32 maximum `3.40282e38`. Removing the rule visitor
leaves `calc(infinity)` arriving unsaturated, which is how that split was
measured.

Below the threshold the binding wall is lightningcss's six-significant-digit
serializer, not the float32 ULP grid, and it bites from about `1e6` — far below
`2 ** 32`. `1234567` arrives as `1234570`, `12345678` as `12345700`, `123456789`
as `123457000`. Sampling forty full-precision hues per decade, the arriving hue
differs from the authored one in 38/40 of `[1e6, 1e7)` and 40/40 of every decade
above. So the claim that the arriving float still resolves finer than the turn
below the threshold was false over three decades of that range.

The guard's own effect was miscounted too. It turns twelve assertions across the
two files from red to green, three of them parity rows — `hsl(4294967296 …)`,
`hsl(1e20 …)` and `hsl(1e38 …)`. The earlier "six more rows" counted hues
measured to resolve red, half of which are not rows in the parity list at all.

Two test gaps close with it.

The tests bounded the constant from above and barely from below: every value
from `10000001` to `4294969856` left all 55 assertions green, a 429-fold
interval that `2 ** 31` sat inside. The `720` row was the reason — 720 reduces
to 0, the same answer the clamp gives, so it survived a mutation that coerced
every hue while both its siblings went red. Replacing it with `3e9` closes both
gaps: `3e9` reduces to 120°, which the clamp does not produce, and it sits
between `2 ** 31` and `2 ** 32` where the ULP is 256 and so still finer than a
turn. The green window is now `[3000000001, 4294969856]`, a factor of 1.43, and
`2 ** 31` is red. `3e9` also arrives exactly — one significant digit survives
any serializer — so the pin does not depend on the loss described above.

`Number.isFinite(hue) &&` was dead. `Math.abs(NaN) < x` and
`Math.abs(±Infinity) < x` are both `false`, so the clause could not change a
result; removing it leaves all 55 assertions green. The comment now says why no
finiteness test is needed rather than carrying one that does nothing.

Full suite unchanged: `2 failed, 4 skipped, 53 passed, 55 of 59 total` and
`3 failed, 21 skipped, 1084 passed, 1108 total`, the 3 being the two
`src/__tests__/babel/*` suites that fail at every ref on Windows. `yarn
typecheck` and `yarn lint` exit 0.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review August 15, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant